import { AxisAlignedBoundingBox } from '../Bound/AxisAlignedBoundingBox';
import { BoundingSphere } from '../Bound/BoundingSphere';
import { Cartesian2 } from '../../Math/Cartesian2';
import { Cartesian3 } from '../../Math/Cartesian3';
import { CesiumMath } from '../../Math/CesiumMath';
import { CesiumMatrix4 as Matrix4 } from '../../Math/CesiumMatrix4';
import { defaultValue } from '../../Util/defaultValue';
import { defined } from '../../Util/defined';
import { DeveloperError } from '../../Util/DeveloperError';
import { Ellipsoid } from '../../Math/Ellipsoid/Ellipsoid';
import { EllipsoidalOccluder } from '../../Math/Ellipsoid/EllipsoidalOccluder';
import { OrientedBoundingBox } from '../Bound/OrientedBoundingBox';
import { Rectangle } from '../../Math/Rectangle';
import { TerrainEncoding } from '../Terrain/TerrainEncoding';
import { Transforms } from '../../Math/Transforms';
import { WebMercatorProjection } from '../../Layer/ImageryLayer/WebMercatorProjection';

/**
 * Contains functions to create a mesh from a heightmap image.
 *
 * @namespace HeightmapTessellator
 *
 * @private
 */
const HeightmapTessellator:any = {};

/**
  * The default structure of a heightmap, as given to {@link HeightmapTessellator.computeVertices}.
  *
  * @constant
  */
HeightmapTessellator.DEFAULT_STRUCTURE = Object.freeze({
    heightScale: 1.0,
    heightOffset: 0.0,
    elementsPerHeight: 1,
    stride: 1,
    elementMultiplier: 256.0,
    isBigEndian: false
});

const cartesian3Scratch = new Cartesian3();
const matrix4Scratch = new Matrix4();
const minimumScratch = new Cartesian3();
const maximumScratch = new Cartesian3();

/**
  * Fills an array of vertices from a heightmap image.
  *
  * @param {Object} options Object with the following properties:
  * @param {Int8Array|Uint8Array|Int16Array|Uint16Array|Int32Array|Uint32Array|Float32Array|Float64Array} options.heightmap The heightmap to tessellate.
  * @param {Number} options.width The width of the heightmap, in height samples.
  * @param {Number} options.height The height of the heightmap, in height samples.
  * @param {Number} options.skirtHeight The height of skirts to drape at the edges of the heightmap.
  * @param {Rectangle} options.nativeRectangle A rectangle in the native coordinates of the heightmap's projection.  For
  *                 a heightmap with a geographic projection, this is degrees.  For the web mercator
  *                 projection, this is meters.
  * @param {Number} [options.exaggeration=1.0] The scale used to exaggerate the terrain.
  * @param {Number} [options.exaggerationRelativeHeight=0.0] The height from which terrain is exaggerated.
  * @param {Rectangle} [options.rectangle] The rectangle covered by the heightmap, in geodetic coordinates with north, south, east and
  *                 west properties in radians.  Either rectangle or nativeRectangle must be provided.  If both
  *                 are provided, they're assumed to be consistent.
  * @param {Boolean} [options.isGeographic=true] True if the heightmap uses a {@link GeographicProjection}, or false if it uses
  *                  a {@link WebMercatorProjection}.
  * @param {Cartesian3} [options.relativeToCenter=Cartesian3.ZERO] The positions will be computed as <code>Cartesian3.subtract(worldPosition, relativeToCenter)</code>.
  * @param {Ellipsoid} [options.ellipsoid=Ellipsoid.WGS84] The ellipsoid to which the heightmap applies.
  * @param {Object} [options.structure] An object describing the structure of the height data.
  * @param {Number} [options.structure.heightScale=1.0] The factor by which to multiply height samples in order to obtain
  *                 the height above the heightOffset, in meters.  The heightOffset is added to the resulting
  *                 height after multiplying by the scale.
  * @param {Number} [options.structure.heightOffset=0.0] The offset to add to the scaled height to obtain the final
  *                 height in meters.  The offset is added after the height sample is multiplied by the
  *                 heightScale.
  * @param {Number} [options.structure.elementsPerHeight=1] The number of elements in the buffer that make up a single height
  *                 sample.  This is usually 1, indicating that each element is a separate height sample.  If
  *                 it is greater than 1, that number of elements together form the height sample, which is
  *                 computed according to the structure.elementMultiplier and structure.isBigEndian properties.
  * @param {Number} [options.structure.stride=1] The number of elements to skip to get from the first element of
  *                 one height to the first element of the next height.
  * @param {Number} [options.structure.elementMultiplier=256.0] The multiplier used to compute the height value when the
  *                 stride property is greater than 1.  For example, if the stride is 4 and the strideMultiplier
  *                 is 256, the height is computed as follows:
  *                 `height = buffer[index] + buffer[index + 1] * 256 + buffer[index + 2] * 256 * 256 + buffer[index + 3] * 256 * 256 * 256`
  *                 This is assuming that the isBigEndian property is false.  If it is true, the order of the
  *                 elements is reversed.
  * @param {Number} [options.structure.lowestEncodedHeight] The lowest value that can be stored in the height buffer.  Any heights that are lower
  *                 than this value after encoding with the `heightScale` and `heightOffset` are clamped to this value.  For example, if the height
  *                 buffer is a `Uint16Array`, this value should be 0 because a `Uint16Array` cannot store negative numbers.  If this parameter is
  *                 not specified, no minimum value is enforced.
  * @param {Number} [options.structure.highestEncodedHeight] The highest value that can be stored in the height buffer.  Any heights that are higher
  *                 than this value after encoding with the `heightScale` and `heightOffset` are clamped to this value.  For example, if the height
  *                 buffer is a `Uint16Array`, this value should be `256 * 256 - 1` or 65535 because a `Uint16Array` cannot store numbers larger
  *                 than 65535.  If this parameter is not specified, no maximum value is enforced.
  * @param {Boolean} [options.structure.isBigEndian=false] Indicates endianness of the elements in the buffer when the
  *                  stride property is greater than 1.  If this property is false, the first element is the
  *                  low-order element.  If it is true, the first element is the high-order element.
  *
  * @example
  * var width = 5;
  * var height = 5;
  * var statistics = Cesium.HeightmapTessellator.computeVertices({
  *     heightmap : [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0],
  *     width : width,
  *     height : height,
  *     skirtHeight : 0.0,
  *     nativeRectangle : {
  *         west : 10.0,
  *         east : 20.0,
  *         south : 30.0,
  *         north : 40.0
  *     }
  * });
  *
  * var encoding = statistics.encoding;
  * var position = encoding.decodePosition(statistics.vertices, index);
  */
HeightmapTessellator.computeVertices = function (options: any) {
    // >>includeStart('debug', pragmas.debug);
    if (!defined(options) || !defined(options.heightmap)) {
        throw new DeveloperError('options.heightmap is required.');
    }
    if (!defined(options.width) || !defined(options.height)) {
        throw new DeveloperError('options.width and options.height are required.');
    }
    if (!defined(options.nativeRectangle)) {
        throw new DeveloperError('options.nativeRectangle is required.');
    }
    if (!defined(options.skirtHeight)) {
        throw new DeveloperError('options.skirtHeight is required.');
    }
    // >>includeEnd('debug');

    // This function tends to be a performance hotspot for terrain rendering,
    // so it employs a lot of inlining and unrolling as an optimization.
    // In particular, the functionality of Ellipsoid.cartographicToCartesian
    // is inlined.

    const cos = Math.cos;
    const sin = Math.sin;
    const sqrt = Math.sqrt;
    const atan = Math.atan;
    const exp = Math.exp;
    const piOverTwo = CesiumMath.PI_OVER_TWO;
    const toRadians = CesiumMath.toRadians;

    const heightmap = options.heightmap;
    const width = options.width;
    const height = options.height;
    const skirtHeight = options.skirtHeight;
    const hasSkirts = skirtHeight > 0.0;

    const isGeographic = defaultValue(options.isGeographic, true);
    const ellipsoid = defaultValue(options.ellipsoid, Ellipsoid.WGS84);

    const oneOverGlobeSemimajorAxis = 1.0 / ellipsoid.maximumRadius;

    const nativeRectangle = Rectangle.clone(options.nativeRectangle) as Rectangle;
    const rectangle = Rectangle.clone(options.rectangle);

    let geographicWest;
    let geographicSouth;
    let geographicEast;
    let geographicNorth;

    if (!defined(rectangle)) {
        if (isGeographic) {
            geographicWest = toRadians(nativeRectangle.west);
            geographicSouth = toRadians(nativeRectangle.south);
            geographicEast = toRadians(nativeRectangle.east);
            geographicNorth = toRadians(nativeRectangle.north);
        } else {
            geographicWest = nativeRectangle.west * oneOverGlobeSemimajorAxis;
            geographicSouth =
         piOverTwo -
         2.0 * atan(exp(-nativeRectangle.south * oneOverGlobeSemimajorAxis));
            geographicEast = nativeRectangle.east * oneOverGlobeSemimajorAxis;
            geographicNorth =
         piOverTwo -
         2.0 * atan(exp(-nativeRectangle.north * oneOverGlobeSemimajorAxis));
        }
    } else {
        geographicWest = (rectangle as Rectangle).west;
        geographicSouth = (rectangle as Rectangle).south;
        geographicEast = (rectangle as Rectangle).east;
        geographicNorth = (rectangle as Rectangle).north;
    }

    let relativeToCenter = options.relativeToCenter;
    const hasRelativeToCenter = defined(relativeToCenter);
    relativeToCenter = hasRelativeToCenter ? relativeToCenter : Cartesian3.ZERO;
    const includeWebMercatorT = defaultValue(options.includeWebMercatorT, false);

    const exaggeration = defaultValue(options.exaggeration, 1.0);
    const exaggerationRelativeHeight = defaultValue(
        options.exaggerationRelativeHeight,
        0.0
    );
    const hasExaggeration = exaggeration !== 1.0;
    const includeGeodeticSurfaceNormals = hasExaggeration;

    const structure = defaultValue(
        options.structure,
        HeightmapTessellator.DEFAULT_STRUCTURE
    );
    const heightScale = defaultValue(
        structure.heightScale,
        HeightmapTessellator.DEFAULT_STRUCTURE.heightScale
    );
    const heightOffset = defaultValue(
        structure.heightOffset,
        HeightmapTessellator.DEFAULT_STRUCTURE.heightOffset
    );
    const elementsPerHeight = defaultValue(
        structure.elementsPerHeight,
        HeightmapTessellator.DEFAULT_STRUCTURE.elementsPerHeight
    );
    const stride = defaultValue(
        structure.stride,
        HeightmapTessellator.DEFAULT_STRUCTURE.stride
    );
    const elementMultiplier = defaultValue(
        structure.elementMultiplier,
        HeightmapTessellator.DEFAULT_STRUCTURE.elementMultiplier
    );
    const isBigEndian = defaultValue(
        structure.isBigEndian,
        HeightmapTessellator.DEFAULT_STRUCTURE.isBigEndian
    );

    let rectangleWidth = Rectangle.computeWidth(nativeRectangle);
    let rectangleHeight = Rectangle.computeHeight(nativeRectangle);

    const granularityX = rectangleWidth / (width - 1);
    const granularityY = rectangleHeight / (height - 1);

    if (!isGeographic) {
        rectangleWidth *= oneOverGlobeSemimajorAxis;
        rectangleHeight *= oneOverGlobeSemimajorAxis;
    }

    const radiiSquared = ellipsoid.radiiSquared;
    const radiiSquaredX = radiiSquared.x;
    const radiiSquaredY = radiiSquared.y;
    const radiiSquaredZ = radiiSquared.z;

    let minimumHeight = 65536.0;
    let maximumHeight = -65536.0;

    const fromENU = Transforms.eastNorthUpToFixedFrame(relativeToCenter, ellipsoid);
    const toENU = Matrix4.inverseTransformation(fromENU, matrix4Scratch);

    let southMercatorY;
    let oneOverMercatorHeight;
    if (includeWebMercatorT) {
        southMercatorY = WebMercatorProjection.geodeticLatitudeToMercatorAngle(
            geographicSouth
        );
        oneOverMercatorHeight =
       1.0 /
       (WebMercatorProjection.geodeticLatitudeToMercatorAngle(geographicNorth) -
         southMercatorY);
    }

    const minimum = minimumScratch;
    minimum.x = Number.POSITIVE_INFINITY;
    minimum.y = Number.POSITIVE_INFINITY;
    minimum.z = Number.POSITIVE_INFINITY;

    const maximum = maximumScratch;
    maximum.x = Number.NEGATIVE_INFINITY;
    maximum.y = Number.NEGATIVE_INFINITY;
    maximum.z = Number.NEGATIVE_INFINITY;

    let hMin = Number.POSITIVE_INFINITY;

    const gridVertexCount = width * height;
    const edgeVertexCount = skirtHeight > 0.0 ? width * 2 + height * 2 : 0;
    const vertexCount = gridVertexCount + edgeVertexCount;

    const positions = new Array(vertexCount);
    const heights = new Array(vertexCount);
    const uvs = new Array(vertexCount);
    const webMercatorTs = includeWebMercatorT ? new Array(vertexCount) : [];
    const geodeticSurfaceNormals = includeGeodeticSurfaceNormals
        ? new Array(vertexCount)
        : [];

    let startRow = 0;
    let endRow = height;
    let startCol = 0;
    let endCol = width;

    if (hasSkirts) {
        --startRow;
        ++endRow;
        --startCol;
        ++endCol;
    }

    const skirtOffsetPercentage = 0.00001;

    for (let rowIndex = startRow; rowIndex < endRow; ++rowIndex) {
        let row = rowIndex;
        if (row < 0) {
            row = 0;
        }
        if (row >= height) {
            row = height - 1;
        }

        let latitude = nativeRectangle.north - granularityY * row;

        if (!isGeographic) {
            latitude =
         piOverTwo - 2.0 * atan(exp(-latitude * oneOverGlobeSemimajorAxis));
        } else {
            latitude = toRadians(latitude);
        }

        let v = (latitude - geographicSouth) / (geographicNorth - geographicSouth);
        v = CesiumMath.clamp(v, 0.0, 1.0);

        const isNorthEdge = rowIndex === startRow;
        const isSouthEdge = rowIndex === endRow - 1;
        if (skirtHeight > 0.0) {
            if (isNorthEdge) {
                latitude += skirtOffsetPercentage * rectangleHeight;
            } else if (isSouthEdge) {
                latitude -= skirtOffsetPercentage * rectangleHeight;
            }
        }

        const cosLatitude = cos(latitude);
        const nZ = sin(latitude);
        const kZ = radiiSquaredZ * nZ;

        let webMercatorT;
        if (includeWebMercatorT) {
            webMercatorT =
         (WebMercatorProjection.geodeticLatitudeToMercatorAngle(latitude) -
           (southMercatorY as number)) *
         (oneOverMercatorHeight as number);
        }

        for (let colIndex = startCol; colIndex < endCol; ++colIndex) {
            let col = colIndex;
            if (col < 0) {
                col = 0;
            }
            if (col >= width) {
                col = width - 1;
            }

            const terrainOffset = row * (width * stride) + col * stride;

            let heightSample;
            if (elementsPerHeight === 1) {
                heightSample = heightmap[terrainOffset];
            } else {
                heightSample = 0;

                let elementOffset;
                if (isBigEndian) {
                    for (
                        elementOffset = 0;
                        elementOffset < elementsPerHeight;
                        ++elementOffset
                    ) {
                        heightSample =
               heightSample * elementMultiplier +
               heightmap[terrainOffset + elementOffset];
                    }
                } else {
                    for (
                        elementOffset = elementsPerHeight - 1;
                        elementOffset >= 0;
                        --elementOffset
                    ) {
                        heightSample =
               heightSample * elementMultiplier +
               heightmap[terrainOffset + elementOffset];
                    }
                }
            }

            heightSample = heightSample * heightScale + heightOffset;

            maximumHeight = Math.max(maximumHeight, heightSample);
            minimumHeight = Math.min(minimumHeight, heightSample);

            let longitude = nativeRectangle.west + granularityX * col;

            if (!isGeographic) {
                longitude = longitude * oneOverGlobeSemimajorAxis;
            } else {
                longitude = toRadians(longitude);
            }

            let u = (longitude - geographicWest) / (geographicEast - geographicWest);
            u = CesiumMath.clamp(u, 0.0, 1.0);

            let index = row * width + col;

            if (skirtHeight > 0.0) {
                const isWestEdge = colIndex === startCol;
                const isEastEdge = colIndex === endCol - 1;
                const isEdge = isNorthEdge || isSouthEdge || isWestEdge || isEastEdge;
                const isCorner =
           (isNorthEdge || isSouthEdge) && (isWestEdge || isEastEdge);
                if (isCorner) {
                    // Don't generate skirts on the corners.
                    continue;
                } else if (isEdge) {
                    heightSample -= skirtHeight;

                    if (isWestEdge) {
                        // The outer loop iterates north to south but the indices are ordered south to north, hence the index flip below
                        index = gridVertexCount + (height - row - 1);
                        longitude -= skirtOffsetPercentage * rectangleWidth;
                    } else if (isSouthEdge) {
                        // Add after west indices. South indices are ordered east to west.
                        index = gridVertexCount + height + (width - col - 1);
                    } else if (isEastEdge) {
                        // Add after west and south indices. East indices are ordered north to south. The index is flipped like above.
                        index = gridVertexCount + height + width + row;
                        longitude += skirtOffsetPercentage * rectangleWidth;
                    } else if (isNorthEdge) {
                        // Add after west, south, and east indices. North indices are ordered west to east.
                        index = gridVertexCount + height + width + height + col;
                    }
                }
            }

            const nX = cosLatitude * cos(longitude);
            const nY = cosLatitude * sin(longitude);

            const kX = radiiSquaredX * nX;
            const kY = radiiSquaredY * nY;

            const gamma = sqrt(kX * nX + kY * nY + kZ * nZ);
            const oneOverGamma = 1.0 / gamma;

            const rSurfaceX = kX * oneOverGamma;
            const rSurfaceY = kY * oneOverGamma;
            const rSurfaceZ = kZ * oneOverGamma;

            const position = new Cartesian3();
            position.x = rSurfaceX + nX * heightSample;
            position.y = rSurfaceY + nY * heightSample;
            position.z = rSurfaceZ + nZ * heightSample;

            Matrix4.multiplyByPoint(toENU, position, cartesian3Scratch);
            Cartesian3.minimumByComponent(cartesian3Scratch, minimum, minimum);
            Cartesian3.maximumByComponent(cartesian3Scratch, maximum, maximum);
            hMin = Math.min(hMin, heightSample);

            positions[index] = position;
            uvs[index] = new Cartesian2(u, v);
            heights[index] = heightSample;

            if (includeWebMercatorT) {
                webMercatorTs[index] = webMercatorT;
            }

            if (includeGeodeticSurfaceNormals) {
                geodeticSurfaceNormals[index] = ellipsoid.geodeticSurfaceNormal(
                    position
                );
            }
        }
    }

    const boundingSphere3D = BoundingSphere.fromPoints(positions);
    let orientedBoundingBox;
    if (defined(rectangle)) {
        orientedBoundingBox = OrientedBoundingBox.fromRectangle(
            (rectangle as Rectangle),
            minimumHeight,
            maximumHeight,
            ellipsoid
        );
    }

    let occludeePointInScaledSpace;
    if (hasRelativeToCenter) {
        const occluder = new EllipsoidalOccluder(ellipsoid);
        occludeePointInScaledSpace = occluder.computeHorizonCullingPointPossiblyUnderEllipsoid(
            relativeToCenter,
            positions,
            minimumHeight
        );
    }

    const aaBox = new AxisAlignedBoundingBox(minimum, maximum, relativeToCenter);
    const encoding = new TerrainEncoding(
        relativeToCenter,
        aaBox,
        hMin,
        maximumHeight,
        fromENU,
        false,
        includeWebMercatorT,
        includeGeodeticSurfaceNormals,
        exaggeration,
        exaggerationRelativeHeight
    );
    const vertices = new Float32Array(vertexCount * encoding.stride);

    let bufferIndex = 0;
    for (let j = 0; j < vertexCount; ++j) {
        bufferIndex = encoding.encode(
            vertices,
            bufferIndex,
            positions[j],
            uvs[j],
            heights[j],
            undefined,
            webMercatorTs[j],
            geodeticSurfaceNormals[j]
        );
    }

    return {
        vertices: vertices,
        maximumHeight: maximumHeight,
        minimumHeight: minimumHeight,
        encoding: encoding,
        boundingSphere3D: boundingSphere3D,
        orientedBoundingBox: orientedBoundingBox,
        occludeePointInScaledSpace: occludeePointInScaledSpace
    };
};
export default HeightmapTessellator;
